iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Software Development

重啟挑戰:老派軟體工程師的測試修練系列 第 13

Day 13 – NSubstitute 與 AutoFixture 的整合應用

  • 分享至 

  • xImage
  •  

前言

前面學會了 NSubstitute 的依賴模擬和 AutoFixture 的資料產生。但實際開發時,當服務類別有多個相依性,手動建立每個 Mock 物件會讓測試程式碼變得冗長。AutoFixture.AutoData 提供了更簡潔的解決方案,可以自動處理相依性注入並產生測試資料。

這篇來看看如何結合 AutoFixture.AutoData 與 NSubstitute,讓測試寫得更有效率。

AutoFixture.AutoNSubstitute 套件介紹

套件概述

AutoFixture.AutoNSubstitute 是 AutoFixture 生態系統中的一個擴充套件,專門用來整合 NSubstitute 模擬框架。它提供了自動模擬(Auto-Mocking)功能,能夠自動為介面和抽象類別建立 NSubstitute 的替身物件。

NuGet Package: AutoFixture.AutoNSubstitute
套件連結: https://www.nuget.org/packages/AutoFixture.AutoNSubstitute/
官方文件: https://github.com/autofixture/autofixture#mocking-libraries

安裝 NuGet Package:

dotnet add package AutoFixture.AutoNSubstitute

AutoNSubstituteCustomization 的作用

當我們在 AutoFixture 中加入 AutoNSubstituteCustomization 時,它會自動:

  1. 偵測介面類型:當 AutoFixture 遇到介面或抽象類別時
  2. 自動建立替身:使用 NSubstitute 的 Substitute.For<T>() 建立 Mock 物件
  3. 注入相依性:將這些替身物件注入到需要的建構函式中
  4. 保持實例一致性:確保相同類型的替身在同一個測試中保持一致

基本使用範例

using AutoFixture;
using AutoFixture.AutoNSubstitute;

// 建立包含 AutoNSubstitute 功能的 Fixture
var fixture = new Fixture().Customize(new AutoNSubstituteCustomization());

// 自動建立服務和其相依性
var service = fixture.Create<MyService>();

// MyService 的所有介面相依性都會自動變成 NSubstitute 的替身

傳統方式 vs AutoNSubstitute 方式

傳統手動方式

[Test]
public void TraditionalWay()
{
    // Arrange - 手動建立每個相依性
    var repository = Substitute.For<IRepository>();
    var logger = Substitute.For<ILogger>();
    var notificationService = Substitute.For<INotificationService>();
    var sut = new OrderService(repository, logger, notificationService);

    // 設定替身行為
    repository.GetOrder(Arg.Any<int>()).Returns(someOrder);
    
    // Act & Assert...
}

使用 AutoNSubstitute

[Theory]
[AutoData]
public void WithAutoNSubstitute(OrderService sut, [Frozen] IRepository repository)
{
    // Arrange - 相依性已自動建立,只需設定需要的行為
    repository.GetOrder(Arg.Any<int>()).Returns(someOrder);
    
    // Act & Assert...
}

解決的核心問題

AutoFixture.AutoNSubstitute 主要解決以下問題:

  1. 減少樣板程式碼:不需要手動建立每個介面的替身
  2. 簡化複雜相依性:自動處理多層相依性的建立
  3. 提升測試維護性:當建構函式變更時,測試程式碼不需要同步修改
  4. 保持測試重點:讓開發者專注於測試邏輯而非物件建立

AutoFixture.AutoData 的核心概念

FrozenAttribute 的作用

在 AutoFixture.Xunit 中,[Frozen] 屬性用來控制測試中某個類型的實例。當參數被標註為 [Frozen] 時,AutoFixture 會建立這個類別的一個實例並凍結它,後續在測試方法中都會使用同一個已凍結的實例。

這個機制特別適合有許多相依注入的測試目標類別,可以保證測試的穩定性和一致性。

建立自訂 AutoData 屬性

首先,我們需要建立一個自訂的 AutoData 屬性來整合 AutoNSubstitute。

實作範例:ShipperService 測試

目標類別結構

using AutoNSubstitute.Core.Dto;
using AutoNSubstitute.Core.Entities;
using AutoNSubstitute.Core.Misc;
using AutoNSubstitute.Core.Repositories;
using AutoNSubstitute.Core.Validation;
using MapsterMapper;
using Throw;

namespace AutoNSubstitute.Core.Services;

/// <summary>
/// 出貨商服務實作
/// </summary>
public class ShipperService : IShipperService
{
    private readonly IMapper _mapper;
    private readonly IShipperRepository _shipperRepository;

    /// <summary>
    /// 建構函式
    /// </summary>
    /// <param name="mapper">對應器</param>
    /// <param name="shipperRepository">出貨商資料庫</param>
    public ShipperService(IMapper mapper, IShipperRepository shipperRepository)
    {
        this._mapper = mapper;
        this._shipperRepository = shipperRepository;
    }

    /// <summary>
    /// 以 ShipperId 查詢資料是否存在
    /// </summary>
    /// <param name="shipperId">出貨商編號</param>
    /// <returns>是否存在</returns>
    public async Task<bool> IsExistsAsync(int shipperId)
    {
        shipperId.Throw().IfLessThanOrEqualTo(0);
        var exists = await this._shipperRepository.IsExistsAsync(shipperId);
        return exists;
    }

    /// <summary>
    /// 以 ShipperId 查詢出貨商資料
    /// </summary>
    /// <param name="shipperId">出貨商編號</param>
    /// <returns>出貨商資料</returns>
    public async Task<ShipperDto> GetAsync(int shipperId)
    {
        shipperId.Throw().IfLessThanOrEqualTo(0);

        var exists = await this._shipperRepository.IsExistsAsync(shipperId);
        if (!exists)
        {
            return null;
        }

        var model = await this._shipperRepository.GetAsync(shipperId);
        var shipper = this._mapper.Map<ShipperModel, ShipperDto>(model);
        return shipper;
    }

    /// <summary>
    /// 搜尋出貨商資料
    /// </summary>
    /// <param name="companyName">公司名稱</param>
    /// <param name="phone">電話號碼</param>
    /// <returns>符合條件的出貨商資料</returns>
    public async Task<IEnumerable<ShipperDto>> SearchAsync(string companyName, string phone)
    {
        if (string.IsNullOrWhiteSpace(companyName) && string.IsNullOrWhiteSpace(phone))
        {
            throw new ArgumentException("companyName 與 phone 不可都為空白");
        }

        var totalCount = await this.GetTotalCountAsync();
        if (totalCount.Equals(0))
        {
            return [];
        }

        var models = await this._shipperRepository.SearchAsync(companyName ?? string.Empty, phone ?? string.Empty);
        var shippers = this._mapper.Map<IEnumerable<ShipperDto>>(models);
        return shippers;
    }

    /// <summary>
    /// 新增
    /// </summary>
    /// <param name="shipper">出貨商資料</param>
    /// <returns>執行結果</returns>
    public async Task<IResult> CreateAsync(ShipperDto shipper)
    {
        ModelValidator.Validate(shipper, nameof(shipper));

        var model = this._mapper.Map<ShipperDto, ShipperModel>(shipper);
        var result = await this._shipperRepository.CreateAsync(model);
        return result;
    }

    // 其他方法實作...
}

設定 Mapster 客製化

因為測試目標使用 Mapster 而非 AutoMapper,我們需要建立對應的客製化。

為什麼不讓 AutoNSubstitute 自動處理?

雖然 AutoNSubstitute 可以自動為 IMapper 介面建立替身物件,但這並不是我們想要的結果:

  1. IMapper 是工具型相依性:它不是業務邏輯的一部分,而是負責物件對應的工具
  2. 需要真實的對應設定:測試中需要驗證對應邏輯是否正確,使用 Mock 會失去這個驗證能力
  3. 設定複雜度:如果使用 Mock,需要為每個對應方法設定 Returns,反而增加測試複雜度
  4. 測試意圖:我們要測試的是業務邏輯,不是 IMapper 本身的行為

因此,我們選擇建立真實的 Mapster 設定,讓 AutoFixture 注入已設定好的 IMapper 實例:

using AutoFixture;
using AutoNSubstitute.Core.MapConfig;
using Mapster;
using MapsterMapper;

namespace AutoNSubstitute.Tests.AutoFixtureConfigurations;

/// <summary>
/// Mapster 對應器客製化
/// </summary>
public class MapsterMapperCustomization : ICustomization
{
    /// <summary>
    /// 客製化 Fixture
    /// </summary>
    /// <param name="fixture">Fixture 實例</param>
    public void Customize(IFixture fixture)
    {
        fixture.Register(() => this.Mapper);
    }

    private IMapper? _mapper;

    private IMapper Mapper
    {
        get
        {
            if (this._mapper is not null)
            {
                return this._mapper;
            }

            var typeAdapterConfig = new TypeAdapterConfig();
            typeAdapterConfig.Scan(typeof(ServiceMapRegister).Assembly);
            this._mapper = new Mapper(typeAdapterConfig);
            return this._mapper;
        }
    }
}

建立自訂 AutoData 屬性

為了在測試中同時使用 AutoNSubstitute 的自動模擬功能和 Mapster 的真實對應器,我們需要建立自訂的 AutoData 屬性。

AutoDataWithCustomizationAttribute 的設計目的

  1. 整合多種客製化:將 AutoNSubstituteCustomization 和 MapsterMapperCustomization 組合在一起
  2. 簡化測試設定:避免在每個測試方法中重複設定 Fixture
  3. 標準化測試模式:為整個專案提供一致的測試基礎設施
  4. 封裝複雜性:將 Fixture 的複雜設定隱藏在屬性內部

CreateFixture 方法的處理行為

  • 建立基礎 Fixture:使用 new Fixture() 建立基本的 AutoFixture 實例
  • 加入 AutoNSubstitute 支援:透過 .Customize(new AutoNSubstituteCustomization()) 啟用自動模擬
  • 加入 Mapster 支援:透過 .Customize(new MapsterMapperCustomization()) 注入真實的對應器設定
  • 鏈式呼叫:使用 Fluent API 讓多個客製化設定可以連續套用
using AutoFixture;
using AutoFixture.AutoNSubstitute;
using AutoFixture.Xunit2;

namespace AutoNSubstitute.Tests.AutoFixtureConfigurations;

/// <summary>
/// 包含客製化設定的 AutoData 屬性
/// </summary>
public class AutoDataWithCustomizationAttribute : AutoDataAttribute
{
    /// <summary>
    /// 建構函式
    /// </summary>
    public AutoDataWithCustomizationAttribute() : base(CreateFixture)
    {
    }

    private static IFixture CreateFixture()
    {
        var fixture = new Fixture().Customize(new AutoNSubstituteCustomization())
                                   .Customize(new MapsterMapperCustomization());

        return fixture;
    }
}

建立 InlineAutoData 版本

在某些測試場景中,我們需要同時使用固定的測試值(如邊界值、特殊值)和自動產生的物件。這時候就需要 InlineAutoData 的功能。

InlineAutoDataWithCustomizationAttribute 的設計目的

  1. 混合測試資料策略:結合預定義的固定值與 AutoFixture 產生的動態資料
  2. 參數化測試支援:特別適用於需要測試多組邊界值或特殊情況的場景
  3. 保持客製化設定:維持與 AutoDataWithCustomizationAttribute 相同的 Fixture 設定
  4. 提升測試覆蓋率:透過固定值確保關鍵測試案例,透過自動產生擴展測試範圍

與純 AutoData 的差異

  • AutoData:所有參數都由 AutoFixture 自動產生
  • InlineAutoData:前幾個參數使用固定值,其餘參數由 AutoFixture 產生
  • 應用場景:邊界值測試、異常參數測試、多組固定條件測試

CreateFixture 方法的處理行為

  • 重用相同的設定邏輯:與 AutoDataWithCustomizationAttribute 使用完全相同的 CreateFixture 方法
  • 確保一致性:保證兩種屬性在相依性處理上的行為完全一致
  • 簡化維護:當需要修改 Fixture 設定時,只需要在一個地方進行變更
using AutoFixture;
using AutoFixture.AutoNSubstitute;
using AutoFixture.Xunit2;

namespace AutoNSubstitute.Tests.AutoFixtureConfigurations;

/// <summary>
/// 包含客製化設定的 InlineAutoData 屬性
/// </summary>
public class InlineAutoDataWithCustomizationAttribute : InlineAutoDataAttribute
{
    /// <summary>
    /// 建構函式
    /// </summary>
    /// <param name="values">固定值</param>
    public InlineAutoDataWithCustomizationAttribute(params object[] values)
        : base(new AutoDataWithCustomizationAttribute(), values)
    {
    }
}

實作重點說明

為什麼要改用 new AutoDataWithCustomizationAttribute() 而不是 CreateFixture 方法?

  1. InlineAutoDataAttribute 的設計原理

    • InlineAutoDataAttribute 繼承自 CompositeDataAttribute
    • 它需要接收一個 AutoDataAttribute 實例作為資料來源提供者
    • 而不是直接接收 Func<IFixture> 委派
  2. 正確的建構函式簽章

    // InlineAutoDataAttribute 的建構函式
    public InlineAutoDataAttribute(AutoDataAttribute autoDataAttribute, params object[] values)
    
  3. 錯誤實作的問題

    // X 錯誤:直接傳遞方法群組會導致型別不匹配
    public InlineAutoDataWithCustomizationAttribute(params object[] values) 
        : base(CreateFixture, values) // CreateFixture 是 Func<IFixture>,不是 AutoDataAttribute
    
    // O 正確:傳遞 AutoDataAttribute 實例
    public InlineAutoDataWithCustomizationAttribute(params object[] values)
        : base(new AutoDataWithCustomizationAttribute(), values)
    
  4. 重用現有邏輯的優勢

    • 不需要重複實作 CreateFixture 方法
    • 確保與 AutoDataWithCustomizationAttribute 的行為完全一致
    • AutoDataWithCustomizationAttribute 的設定變更時,InlineAutoDataWithCustomizationAttribute 會自動同步
  5. 型別安全性

    • 編譯器會在編譯時期檢查型別匹配
    • 避免執行時期的型別轉換錯誤
    • 確保 AutoFixture 能正確處理介面的 Mock 建立

測試實作範例

ShipperServiceBasicTests.cs

基本測試:無需設定相依行為

[Theory]
[AutoDataWithCustomization]
public async Task IsExistsAsync_輸入的ShipperId為0時_應拋出ArgumentOutOfRangeException(ShipperService sut)
{
    // Arrange
    var shipperId = 0;

    // Act
    var exception = await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
        () => sut.IsExistsAsync(shipperId));

    // Assert
    exception.Message.Should().Contain(nameof(shipperId));
}

在這個測試中,sut(System Under Test)會自動由 AutoFixture 建立,包含所有必要的相依性。

進階測試:設定相依行為

[Theory]
[AutoDataWithCustomization]
public async Task IsExistsAsync_輸入的ShipperId_資料不存在_應回傳false(
    [Frozen] IShipperRepository shipperRepository,
    ShipperService sut)
{
    // Arrange
    var shipperId = 99;
    
    shipperRepository.IsExistsAsync(Arg.Any<int>()).Returns(false);

    // Act
    var actual = await sut.IsExistsAsync(shipperId);

    // Assert
    actual.Should().BeFalse();
}

透過 [Frozen] 屬性,我們可以取得相依性的 Stub,並設定其行為。

使用自動產生的資料

[Theory]
[AutoDataWithCustomization]
public async Task GetAsync_輸入的ShipperId_資料有存在_應回傳model(
    [Frozen] IShipperRepository shipperRepository,
    ShipperService sut,
    ShipperModel model)
{
    // Arrange
    var shipperId = model.ShipperId;

    shipperRepository.IsExistsAsync(Arg.Any<int>()).Returns(true);
    shipperRepository.GetAsync(Arg.Any<int>()).Returns(model);

    // Act
    var actual = await sut.GetAsync(shipperId);

    // Assert
    actual.Should().NotBeNull();
    actual.ShipperId.Should().Be(shipperId);
}

這裡的 model 也是由 AutoFixture 自動產生,包含合理的測試資料。

參數化測試

[Theory]
[InlineAutoDataWithCustomization(0, 10, nameof(from))]
[InlineAutoDataWithCustomization(-1, 10, nameof(from))]
[InlineAutoDataWithCustomization(1, 0, nameof(size))]
[InlineAutoDataWithCustomization(1, -1, nameof(size))]
public async Task GetCollectionAsync_from與size輸入不合規格內容_應拋出ArgumentOutOfRangeException(
    int from, int size, string parameterName, ShipperService sut)
{
    // Act
    var exception = await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
        () => sut.GetCollectionAsync(from, size));

    // Assert
    exception.Message.Should().Contain(parameterName);
}

結合固定的測試數值與自動產生的 SUT。

使用 CollectionSize 控制集合大小

[Theory]
[AutoDataWithCustomization]
public async Task GetAllAsync_資料表裡有10筆資料_回傳的集合裡有10筆(
    [Frozen] IShipperRepository shipperRepository,
    ShipperService sut,
    [CollectionSize(10)] IEnumerable<ShipperModel> models)
{
    // Arrange
    shipperRepository.GetAllAsync().Returns(models);

    // Act
    var actual = await sut.GetAllAsync();

    // Assert
    actual.Should().NotBeEmpty();
    actual.Should().HaveCount(10);
}

複雜的資料設定

[Theory]
[AutoDataWithCustomization]
public async Task SearchAsync_companyName輸入資料_phone無輸入_有符合條件的資料_回傳集合應包含符合條件的資料(
    IFixture fixture,
    [Frozen] IShipperRepository shipperRepository,
    ShipperService sut)
{
    // Arrange
    var models = fixture.Build<ShipperModel>()
                        .With(x => x.CompanyName, "test")
                        .CreateMany(1);

    shipperRepository.GetTotalCountAsync().Returns(10);
    shipperRepository.SearchAsync(Arg.Any<string>(), Arg.Any<string>())
                     .Returns(models);

    const string companyName = "test";
    const string phone = "";

    // Act
    var actual = await sut.SearchAsync(companyName, phone);

    // Assert
    actual.Should().NotBeEmpty();
    actual.Should().HaveCount(1);
    actual.Any(x => x.CompanyName == companyName).Should().BeTrue();
}

驗證參數的測試案例

由於 SearchAsync 方法包含參數驗證邏輯,我們也需要測試這些驗證規則:

[Theory]
[InlineAutoDataWithCustomization(null, null)]
[InlineAutoDataWithCustomization("", "")]
[InlineAutoDataWithCustomization("   ", "   ")]
public async Task SearchAsync_companyName與phone都為空白_應拋出ArgumentException(
    string companyName, string phone, ShipperService sut)
{
    // Act & Assert
    var exception = await Assert.ThrowsAsync<ArgumentException>(
        () => sut.SearchAsync(companyName, phone));
    
    exception.Message.Should().Contain("companyName 與 phone 不可都為空白");
}

在這個範例中,我們透過 IFixture 參數來精確控制測試資料的產生。

整合的優勢與實務考量

核心優勢

  1. 大幅減少樣板程式碼:不需要手動建立每個相依性的 Mock
  2. 自動處理複雜相依圖:AutoFixture 會自動解析並建立所需的物件
  3. 測試資料自動產生:減少寫死在程式裡的測試資料
  4. 保持測試意圖清晰:專注於測試邏輯而非物件建立
  5. 提升開發效率:特別適合有多個相依性的複雜服務類別

注意事項與限制

  1. 學習成本:需要理解 AutoFixture 和 Frozen 的運作機制
  2. 除錯複雜度:自動產生的物件可能讓除錯變得困難
  3. 測試可讀性:過度使用可能讓測試意圖不明確
  4. 效能考量:物件建立的開銷可能影響測試執行速度

適用場景判斷

建議使用的場景

  • 服務層測試,特別是有多個相依性的類別
  • 需要大量測試資料的參數化測試
  • 複雜業務邏輯的驗證

謹慎使用的場景

  • 簡單的單一相依性測試(手動建立可能更清晰)
  • 需要精確控制每個物件屬性的測試
  • 團隊成員對 AutoFixture 不熟悉的專案

實務導入建議

導入策略

  1. 漸進式採用:從簡單的服務類別開始,逐步擴展到複雜場景
  2. 團隊培訓:確保團隊成員理解 AutoFixture 和 Frozen 的概念
  3. 建立規範:制定何時使用自動產生、何時手動建立的準則
  4. 效能監控:注意測試執行時間,避免過度複雜的物件圖

最佳實踐

  1. 明確的測試意圖:即使使用自動產生,測試名稱和斷言仍要清楚表達意圖
  2. 適度的控制:在需要時透過 IFixture 參數精確控制資料產生
  3. 合理的抽象:建立可重用的 Customization 和 AutoData 屬性
  4. 文件化設定:記錄自訂 AutoData 屬性的用途和設定

今日小結

今天學了如何將 NSubstitute 與 AutoFixture 整合,透過 AutoFixture.AutoNSubstitute 套件讓測試寫得更有效率。

關鍵技術要點

  1. AutoNSubstituteCustomization:自動為介面建立 NSubstitute 替身
  2. 自訂 AutoData 屬性:整合多種客製化設定,簡化測試程式碼
  3. FrozenAttribute 機制:確保相同類型的實例在測試中保持一致
  4. 混合測試策略:透過 InlineAutoData 結合固定值與自動產生

實務價值

這種整合方式讓我們能夠:

  • 簡化複雜服務的測試設定:自動處理多個相依性的建立與注入
  • 提升測試維護性:減少重複的物件建立程式碼
  • 保持測試重點:讓開發者專注於測試邏輯本身
  • 建立標準化模式:透過自訂屬性確保整個專案的測試風格一致

學習進程回顧

從 Day 7 學會 NSubstitute 的基本應用,Day 10-12 掌握 AutoFixture 的各種功能,到今天學會兩者的整合應用,現在有了一套完整的測試工具。這些工具讓我們能應對各種複雜的測試場景,同時保持程式碼簡潔好維護。

重要的是要記住,工具是為了解決問題而存在的。實際應用時,要根據專案需求、團隊能力和維護成本來決定是否採用這種方式。測試的可讀性、可維護性和實用性永遠是優先考量。

相關參考資料

明天我們將學習另一個測試資料產生工具 Bogus,探討它與 AutoFixture 的差異,以及在不同場景下的選擇策略。

範例程式碼:


這是「重啟挑戰:老派軟體工程師的測試修練」的第十三天。明天會介紹 Day 14 – Bogus 入門:與 AutoFixture 的差異比較。


上一篇
Day 12 – 結合 AutoData:xUnit 與 AutoFixture 的整合應用
下一篇
Day 14 – Bogus 入門:與 AutoFixture 的差異比較
系列文
重啟挑戰:老派軟體工程師的測試修練23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言